iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Security

從 Detect 到 Proof:智慧合約審計工具實戰系列 第 12 篇

Day 12 | 測試通過,代表沒問題嗎?

  • 分享至 

  • xImage
  •  

Day 7 看過,被攻擊版本的 claimMultiple 跟修補版只差 claimed = reward 和 claimed += reward 這一行。Day 11 最後留了一個問題:這一行怎麼讓攻擊者一直領到錢?

這篇先把 PoC 跑起來,看每一次呼叫實際領了多少,再把答案寫成測試。

這篇的測試是自己挑一組固定的輸入,例如 [13, 13],跑一次看結果對不對,英文叫 example test。後面幾天會換成讓 Foundry 自己產生輸入:

測試 輸入由誰決定 篇數
Example test 自己挑一組 Day 12
Fuzz test Foundry 隨機產生很多組 Day 13
Invariant test Foundry 隨機產生一連串操作 Day 15

在本機重現當時的鏈

PoC 要把 BSC 在區塊 27,830,139 的狀態複製到本機 (fork),再在上面執行。這需要一個能查到舊區塊狀態的節點服務 (archive RPC),我之前用的服務跑到一半就回錯誤。

這次改用 evm-hack-registry 整理好的 anvil_state.json,裡面存了這個事件用到的合約和 storage。用 Foundry 內附的本機節點 anvil 載入後,本機就有一條停在那個區塊的鏈,測試直接 fork 本機:

anvil --port 8546 --load-state anvil_state.json --chain-id 56

我用的 anvil 1.2.3 要把檔案裡區塊資料的 beneficiary 欄位改名成 coinbase 才讀得進去,完整步驟放在實驗 repo 的 days/day12/README.md。

PoC 每一次領了多少

Foundry 執行時可以印出 trace,也就是每一次函式呼叫的參數和回傳值。以下的數字來自 PoC 完整跑一次的 trace。

PoC 第 4 步正常 claim(13) 的時候,claimable 回傳 1.5378 LVL,領走之後 claimed 是 1.5378。

接著 PoC 把時間往後調 5 小時,再呼叫 claimMultiple。這時 claimable(13) 回傳 7.6889 LVL。

變多了,是因為 epoch 13 的獎勵是分段解鎖 (vesting),時間越久能領的越多。算一下,這時解鎖的總額是 1.5378 + 7.6889 = 9.2267 LVL。

然後迴圈裡每一次的 Claimed 事件是這樣交替的:

第幾次 進迴圈時的 claimed claimable 回傳 執行後的 claimed
1 1.5378 9.2267 − 1.5378 = 7.6889 7.6889
2 7.6889 9.2267 − 7.6889 = 1.5378 1.5378
3 1.5378 7.6889 7.6889
… … … …

claimed = reward 直接把 claimed 蓋成「這一次領的量」,沒有累加。下一次 claimable 用解鎖總額減掉它,又算出剩下的那一份。兩次一組,每組領走一整份 9.2267。

陣列長度 2000,就是 1000 組,一次呼叫領走 9,226.7 LVL。

這要靠第 4 步先領過一次。如果進迴圈時 claimed 是 0,第一次就會被蓋成解鎖總額,之後每次都算出 0,只領到正常的量。

沒有檢查重複是真的,但如果 claimed 是累加,重複的 epoch 第二次會算出 0,陣列再長也沒用。

寫成測試

接下來把這件事寫成測試。測試裡的 _earnAndClaimOnce 照 PoC 的方式準備好狀態:在 epoch 13 交易賺到獎勵、結束 epoch、用 claim 正常領一次,再等 5 小時。準備好之後才呼叫 claimMultiple。測試只由一個帳戶來回換 20 次,交易量比 PoC 少,所以下面的數字比 trace 小。

每個測試都記錄呼叫前的 claimable,也就是這時應該能領的量,再用 assertEq 檢查實際拿到的 LVL 是不是等於它。相等就是 PASS,不相等就是 FAIL:

function testDuplicateEpochPaysOnlyClaimable() external {
    _earnAndClaimOnce();
    uint256 remaining = CONTROLLER.claimable(EPOCH, address(this));

    uint256 paid = _claimMultiplePaid(2); // [13, 13]

    assertEq(paid, remaining, "duplicate epoch paid more than claimable");
}

輸入是手選的 [13, 13],不是 PoC 的 2000 個。兩個就夠讓問題出現。

修補後的版本也要跑同一個測試。Foundry 有一些只在測試裡能用的特殊指令,叫 cheatcode,其中 vm.etch 可以直接換掉某個地址上的程式碼。我用它把修補版 0x0e6d6c… 的程式碼蓋到 0x9F00fbD6… 上,其他狀態不動。

結果:

測試 版本 輸入 結果
testSingleEpochPaysClaimable 被攻擊的版本 [13] PASS
testDuplicateEpochPaysOnlyClaimable 被攻擊的版本 [13, 13] FAIL
testFixedDuplicateEpochPaysOnlyClaimable 修補後 [13, 13] PASS

FAIL 的那個,輸出的數字單位是 LVL 的最小單位,除以 10^18 才是 LVL:

[FAIL: duplicate epoch paid more than claimable: 4857801401954857773 != 4048167834962381478]
  [13, 13] vulnerable
    claimable before   4048167834962381478
    claimed before     809633566992476295
    paid               4857801401954857773
    claimed after      809633566992476295

claimable 是 4.048 LVL,實際拿到 4.858 LVL,多出來的剛好是之前領過的 0.810。而且呼叫結束後 claimed 回到 0.810,跟呼叫前一樣:第一個 13 把它蓋成 4.048,第二個 13 又蓋回 0.810。所以下一次呼叫可以再來一遍。

修補後拿到 4.048,claimed 變成 4.858,等於解鎖的總額。

PASS 能說明什麼

testSingleEpochPaysClaimable 只放一個 13,在被攻擊的版本上是 PASS 的:

  [13]
    claimable before   4048167834962381478
    claimed before     809633566992476295
    paid               4048167834962381478
    claimed after      4048167834962381478

拿到的量等於 claimable,測試檢查的東西沒有錯。

但 claimed after 是 4.048,應該是 4.858。帳已經記錯了,只是這個測試沒有檢查 claimed。如果之後再呼叫一次 claimMultiple([13]),claimable 會變成 0.810,前面已經領過的那份可以再領一次。

所以這個 PASS 能說的只有:在這個狀態、這個輸入下,這一次拿到的量是對的。它不能說明這個函式沒有問題。

FAIL 的那個也一樣。它證明 [13, 13] 這條路徑會多付,不能說明還有沒有其他路徑。

明日預告

明天把 epoch 和陣列內容交給 Foundry fuzz 去選,看它能不能自己找到會多付的輸入。

參考資料


上一篇
Day 11|拆解 Level Finance 的 PoC
下一篇
Day 13 | Fuzz Test
系列文
從 Detect 到 Proof:智慧合約審計工具實戰 共 14 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言